Sources/XCMetricsClient/Log Management/LogManager.swift (212 lines of code) (raw):

// Copyright (c) 2020 Spotify AB. // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. import Foundation import XCMetricsUtils enum LogManagerError: Error { /// If a file is not found on disk, this error is thrown. case notFound } protocol LogManager { /// Retrieve logs from the Xcode directory. If no logs recent logs are found, this method will sleep for a fixed /// amount of time in order to let Xcode write the last log. If the log is big, Xcode will take a while to write it. /// This is a best-effort logic, since if the log is written after our timeout, we will upload it during the next /// build anyway. /// - Parameter buildDirectory: The build directory for the current project. /// - Parameter timeout: For how many seconds it should retry waiting for current xcode log. /// - Returns: currentLog (if any has been found) and a set of older logs tuple. func retrieveXcodeLogs(in buildDirectory: String, timeout: Int) throws -> (currentLog: URL?, otherLogs: Set<URL>) /// Retrieve the logs in the cache folder. func retrieveCachedLogs() throws -> Set<URL> /// Moves logs from the given URLs to the Cache folder in order to self-manage their upload and naming. /// - Parameter xcodeLogs: The Xcode logs present in Xcode's directory. /// - Parameter cachedLogs: The logs in the cache directory. /// - Parameter retries: For how many seconds it should retry until all logs to copy are valid func cacheLogs(_ xcodeLogs: Set<URL>, cachedLogs: Set<URL>, retries: Int) throws -> Set<URL> /// Saves a failed request to upload a log to disk, in order to be retried later on. /// - Parameters: /// - url: The URL of the xcactivitylog for which we need to save the request. /// - data: The HTTP body data that we will need to retry sending to the backend. func saveFailedRequest(url: URL, data: Data) throws -> URL /// Removes a request of a failed log. Takes place when such a request is finally delivered successfully. /// - Parameter url: The URL of the request stored on disk. func removeUploadedFailedRequest(url: URL) throws /// Appends UPLOADED to the given xcactivitylog file in order to signal its new uploaded status and prevent log duplication. /// We can't simply delete logs because otherwise during the next run we would copy old logs from the Xcode directory and end up /// with duplicate logs. In this way, we can simply not copy new logs if they are already present in the `xcmetrics` directory. /// - Parameter logURL: The URL to the request func tagLogAsUploaded(logURL: URL) throws -> URL /// Retrieves the cached requests that failed to upload in past runs. func retrieveLogRequestsToUpload() throws -> [URL] /// Removes logs that are old from the cache directory. func evictLogs() throws -> Set<URL> } class LogManagerImplementation: LogManager { /// The directory where the logs that failed to upload will be stored as binary data. static let failedRequestsDirectoryName = "requests" /// What is the maximum log age to mark is as a current private static let maximumCurrentLogAge: TimeInterval = 2 /// The directory where the logs will be managed by XCMetrics. private static let cacheDirectoryName = "XCMetrics" private let projectName: String private let fileAccessor: FileAccessor private let logCopier: LogCopier private let dateProvider: () -> Date private let sleepFunction: (UInt32) -> (UInt32) init( projectName: String, fileAccessor: FileAccessor = FileManagerAccessor(.default), logCopier: LogCopier? = nil, dateProvider: @escaping () -> Date = Date.init, sleepFunction: @escaping (UInt32) -> (UInt32) = sleep ) { self.projectName = projectName self.fileAccessor = fileAccessor self.logCopier = logCopier ?? ZipValidatorLogCopier(fileAccessor: fileAccessor) self.dateProvider = dateProvider self.sleepFunction = sleepFunction } func retrieveXcodeLogs(in buildDirectory: String, timeout: Int) throws -> (currentLog: URL?, otherLogs: Set<URL>){ // Find all logs in Xcode's build and archive directories. let xcodeLogs = findXCActivityLogsInDirectoriesSorted(buildDirectory) let mostRecentLog = xcodeLogs.first let mostRecentLogDate = mostRecentLog?.modificationDate if let mostRecentLog = mostRecentLog, let recentLogDate = mostRecentLogDate, dateProvider().timeIntervalSince(recentLogDate) < LogManagerImplementation.maximumCurrentLogAge { return (mostRecentLog.url, Set(xcodeLogs.dropFirst().map { $0.url })) } // In some cases, Xcode will take a while to write the log (size of the log, CPU usage, etc.). // This is a best-effort logic to try and wait up until the amount of seconds specified before timing out. var timePassed = 0 while timePassed < timeout { _ = sleepFunction(1) timePassed += 1 if let latestLogURL = try? checkIfNewerLogAppeared(in: buildDirectory, afterDate: mostRecentLogDate) { log("Latest log found.") return (latestLogURL, Set(xcodeLogs.map({$0.url}))) } } return (nil, Set(xcodeLogs.map{$0.url})) } func retrieveCachedLogs() throws -> Set<URL> { let logsDirectoryURL = try retrieveOrCreateCachedLogsURL() return Set(try findXCActivityLogsInDirectory(logsDirectoryURL).map { $0.url }) } func cacheLogs(_ xcodeLogs: Set<URL>, cachedLogs: Set<URL>, retries: Int) throws -> Set<URL> { var logsToBeCopied = Array(computeLogsToBeCopied(xcodeLogs: xcodeLogs, cachedLogs: cachedLogs)) var copiedLogs: [URL] = [] var attemptsLeft = retries + 1 while attemptsLeft > 0 && !logsToBeCopied.isEmpty { logsToBeCopied = try logsToBeCopied.filter { logURL in let logsDirectoryURL = try retrieveOrCreateCachedLogsURL() let newLocation = logsDirectoryURL.appendingPathComponent(logURL.lastPathComponent) do { try logCopier.copyLog(from: logURL, to: newLocation) } catch LogCopierError.invalidLog { log("Couldn't copy log because it is invalid") return true } log("Cached log to location: \(newLocation.path)") copiedLogs.append(newLocation) return false } attemptsLeft -= 1 if logsToBeCopied.isEmpty || attemptsLeft <= 0 { break } // For big log files, Xcode will take a while to finish writing the log to the file. // This is a best-effort logic to try `retries` times and wait up until all logs to copy are valid logs. _ = sleepFunction(1) } return Set(copiedLogs) } func saveFailedRequest(url: URL, data: Data) throws -> URL { let failedRequestsDirURL = try retrieveOrCreateRequestsToRetryURL() let failedRequestFileURL = failedRequestsDirURL.appendingPathComponent(url.lastPathComponent).deletingPathExtension() try data.write(to: failedRequestFileURL) return failedRequestFileURL } func removeUploadedFailedRequest(url: URL) throws { try fileAccessor.removeItem(at: url) } func tagLogAsUploaded(logURL: URL) throws -> URL { guard FileManager.default.fileExists(atPath: logURL.path) else { throw LogManagerError.notFound } let directory = logURL.deletingLastPathComponent() let pathExtension = logURL.pathExtension let pathName = logURL.deletingPathExtension().lastPathComponent let uploadedFileURL = directory.appendingPathComponent(pathName + "_UPLOADED." + pathExtension) do { try FileManager.default.moveItem(at: logURL, to: uploadedFileURL) log("Successfully marked log as uploaded: \(uploadedFileURL)") return uploadedFileURL } catch { log("Error (\(error.localizedDescription)) in marking log as uploaded: \(uploadedFileURL)") throw error } } func retrieveLogRequestsToUpload() throws -> [URL] { let directory = try retrieveOrCreateRequestsToRetryURL() let requests = try fileAccessor.entriesOfDirectory(at: directory, options: .skipsHiddenFiles) return requests.map { $0.url } } func evictLogs() throws -> Set<URL> { // Remove logs older than 7 days from cache directory. let cachedLogs = try retrieveCachedLogs() let logsToBeEvicted = cachedLogs.filter { logURL in do { let attributes = try fileAccessor.attributesOfItem(atPath: logURL.path) guard let lastModificationDate = attributes[FileAttributeKey.modificationDate] as? Date else { return false } let components = Calendar.current.dateComponents([.day], from: lastModificationDate, to: dateProvider()) if components.day ?? 0 > 7 { return true } return false } catch { log("Failed to get attributes for item at \(logURL.path): \(error.localizedDescription)") return false } } var removedLogs = Set<URL>() for logURL in logsToBeEvicted { do { // Remove old xcactivitylog. try fileAccessor.removeItem(at: logURL) log("Evicted xcactivitylog at url \(logURL)") removedLogs.insert(logURL) } catch { log("Failed to evict log or upload request for url \(logURL) with error \(error.localizedDescription)") } } return removedLogs } } extension LogManagerImplementation { private func computeLogsToBeCopied(xcodeLogs: Set<URL>, cachedLogs: Set<URL>) -> Set<URL> { var logsToBeCopied = Set<URL>() let managedLogsFileNames = cachedLogs.map { $0.lastPathComponent } for xcodeLog in xcodeLogs { let fileName = xcodeLog.lastPathComponent let pathExtension = xcodeLog.pathExtension let pathName = xcodeLog.deletingPathExtension().lastPathComponent let possibleUpdoadedFileName = pathName + "_\(Constants.uploadedFileNameSuffix)." + pathExtension if !managedLogsFileNames.contains(fileName) && !managedLogsFileNames.contains(possibleUpdoadedFileName) { logsToBeCopied.insert(xcodeLog) } } return logsToBeCopied } private func checkIfNewerLogAppeared(in buildDirectory: String, afterDate: Date?) throws -> URL? { // Sort the logs by modification date in order to find the most recent one. let sortedLogs = findXCActivityLogsInDirectoriesSorted(buildDirectory) // Get the most recent log, if it doesn't exist there's no log in the Xcode folder. guard let mostRecentLog = sortedLogs.first else { return nil } // If there's no after date to compare to, it means this is the first log, so just return it. guard let afterDate = afterDate else { return mostRecentLog.url } // If the log we have found is newer that the one we're comparing to, return it. if mostRecentLog.modificationDate?.compare(afterDate) == .orderedDescending { return mostRecentLog.url } return nil } private func findXCActivityLogsInDirectory(_ url: URL) throws -> [FileEntry] { return try fileAccessor.entriesOfDirectory( at: url, options: .skipsHiddenFiles ) .filter { $0.url.pathExtension == String.xcactivitylog } } private func findXCActivityLogsInDirectoriesSorted(_ buildDirectory: String) -> [FileEntry] { let xcodeLogsDirectoryURL = URL.makeBuildLogsDirectory(for: buildDirectory) let xcodeArchiveLogsDirectoryURL = URL.makeBuildLogsDirectoryWhenArchiving(for: buildDirectory) let buildLogs = (try? findXCActivityLogsInDirectory(xcodeLogsDirectoryURL)) ?? [] let archiveLogs = (try? findXCActivityLogsInDirectory(xcodeArchiveLogsDirectoryURL)) ?? [] return (buildLogs + archiveLogs).sorted(by: { (lhs, rhs) -> Bool in let lhDate = lhs.modificationDate ?? Date.distantPast let rhDate = rhs.modificationDate ?? Date.distantPast return lhDate.compare(rhDate) == .orderedDescending }) } private func retrieveOrCreateCachedLogsURL() throws -> URL { guard let cacheURL = fileAccessor.urls(for: .cachesDirectory, in: .userDomainMask).first else { throw LogParserError.noCacheFolder } let managedLocation = cacheURL .appendingPathComponent(Self.cacheDirectoryName) .appendingPathComponent(self.projectName) if !fileAccessor.fileExists(atPath: managedLocation.path, isDirectory: &.true) { try fileAccessor.createDirectory(at: managedLocation, withIntermediateDirectories: true, attributes: nil) } return managedLocation } private func retrieveOrCreateRequestsToRetryURL() throws -> URL { guard let cacheURL = fileAccessor.urls(for: .cachesDirectory, in: .userDomainMask).first else { throw LogParserError.noCacheFolder } let managedLocation = cacheURL .appendingPathComponent(Self.cacheDirectoryName) .appendingPathComponent(self.projectName) .appendingPathComponent(Self.failedRequestsDirectoryName) if !fileAccessor.fileExists(atPath: managedLocation.path, isDirectory: &.true) { try fileAccessor.createDirectory(at: managedLocation, withIntermediateDirectories: true, attributes: nil) } return managedLocation } }